#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import re
import enum
import pathlib
import paramiko
from cryptography.hazmat.primitives.asymmetric import ec
from ._util import Util
from ._handy import Handy
from ._errors import FileFormatError


class ServerSysCfg:

    def __init__(self, prefix="/", cfg_dir=None, read_only=False):
        self._prefix = prefix

        if cfg_dir is None:
            self._cfgDir = os.path.join(self._prefix, "etc", "ssh")
        else:
            assert not cfg_dir.startswith("/")
            self._cfgDir = os.path.join(self._prefix, cfg_dir)

        self._readOnly = read_only

        try:
            buf = pathlib.Path(self._getCfgFilePath()).read_text()
            self._cfgLines = Util.readListBuffer(buf)
            if not self._readOnly and "\n".join(self._cfgLines) != buf:
                self._writeCfgFile()
        except FileNotFoundError:
            self._cfgLines = []
            if not self._readOnly:
                self._writeCfgFile()

    @property
    def paths(self):
        return [
            self._cfgDir,
            self._getCfgFilePath(),
            self._getHostKeyRsaFilePath(False),
            self._getHostKeyRsaFilePath(True),
            self._getHostKeyEcdsaFilePath(False),
            self._getHostKeyEcdsaFilePath(True),
            self._getHostKeyEd25519FilePath(False),
            self._getHostKeyEd25519FilePath(True),
        ]

    @property
    def cfg_file_path(self):
        return self._getCfgFilePath()

    def list_host_keys(self):
        return [k for k in Handy.VALID_KEY_TYPES if self.has_host_key(k)]

    def has_host_key(self, key_type):
        # "*.pub" file is optional
        if key_type == "rsa":
            keyFile = self._getHostKeyRsaFilePath(False)
        elif key_type == "ecdsa":
            keyFile = self._getHostKeyEcdsaFilePath(False)
        elif key_type == "ed25519":
            keyFile = self._getHostKeyEd25519FilePath(False)
        else:
            assert False

        if not os.path.isfile(keyFile):
            return False

        if not self._cfgDir.endswith("/etc/ssh"):
            keyFile = "/" + os.path.relpath(keyFile, self._prefix)
            if Handy.lineListReSearch(self._cfgLines, f"^HostKey\\s+{re.escape(keyFile)}$") is None:
                return False

        return True

    def generate_host_key(self, key_type, overwrite_existing=False):
        assert not self._readOnly

        if not overwrite_existing and self.has_host_key(key_type):
            return

        if key_type == "rsa":
            keyFile = self._getHostKeyRsaFilePath(False)
            kbuf = paramiko.rsakey.RSAKey.generate(bits=2048)
            kbuf.write_private_key_file(keyFile)
            os.chmod(keyFile, 0o600)
            pathlib.Path(self._getHostKeyRsaFilePath(True)).write_text(f"{kbuf.get_name()} {kbuf.get_base64()}")
        elif key_type == "ecdsa":
            keyFile = self._getHostKeyEcdsaFilePath(False)
            kbuf = paramiko.ecdsakey.ECDSAKey.generate()
            kbuf.write_private_key_file(keyFile)
            os.chmod(keyFile, 0o600)
            pathlib.Path(self._getHostKeyEcdsaFilePath(True)).write_text(f"{kbuf.get_name()} {kbuf.get_base64()}")
        elif key_type == "ed25519":
            keyFile = self._getHostKeyEd25519FilePath(False)
            kbuf = paramiko.ed25519key.Ed25519Key.generate()            # FIXME: this function does not exist
            kbuf.write_private_key_file(keyFile)
            os.chmod(keyFile, 0o600)
            pathlib.Path(self._getHostKeyEd25519FilePath(True)).write_text(f"{kbuf.get_name()} {kbuf.get_base64()}")
        else:
            assert False

        if not self._cfgDir.endswith("/etc/ssh"):
            keyFile = "/" + os.path.relpath(keyFile, self._prefix)
            if not Handy.lineListReSearch(self._cfgLines, f"^HostKey\\s+{re.escape(keyFile)}$") is not None:
                self._cfgLines.append(f"HostKey {keyFile}")
                self._writeCfgFile()

    def get_listen_addresses(self):
        ret = []

        for m in Handy.lineListReSearchMulti(self._cfgLines, r"^ListenAddress (\S+)$"):
            if m.group(1) in ret:
                raise FileFormatError("duplicate listen address \"%s\"" % (m.group(1)))
            if not Util.isValidListenIpAddr(m.group(1)):
                raise FileFormatError("invalid listen address \"%s\"" % (m.group(1)))
            ret.append(m.group(1))

        if len(ret) == 0:
            # default value
            return ["0.0.0.0"]
        else:
            return ret

    def set_listen_addresses(self, value_list):
        assert len(value_list) > 0
        assert all(Util.isValidListenIpAddr(v) for v in value_list)

        oldLines = sorted(self._cfgLines)

        Handy.lineListRemoveLines(self._cfgLines, r"^ListenAddress\s+")
        if value_list != ["0.0.0.0"]:
            for value in value_list:
                self._cfgLines.append(f"ListenAddress {value}")

        if oldLines != sorted(self._cfgLines):
            self._writeCfgFile()

    def get_port(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^Port\s+(\S+)$")
        if m is None:
            # default value
            return 22
        try:
            return int(m.group(1))
        except ValueError:
            raise FileFormatError("invalid port \"%s\"" % (m.group(1)))

    def set_port(self, value):
        assert not self._readOnly
        assert value > 0 and value < 65536

        dst = f"Port {value}" if value != 22 else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^Port\s+", dst):
            self._writeCfgFile()

    def is_using_pam(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^UsePAM\s+(\S+)$")
        if m is None:
            # default value
            return False
        return Handy.yn2bool(m.group(1), "UsePAM")

    def set_using_pam(self, value):
        assert not self._readOnly

        dst = "UsePAM %s" % (Handy.bool2("yn", value)) if value else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^UsePAM\s+", dst):
            self._writeCfgFile()

    def is_printing_motd(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^PrintMotd\s+(\S+)$")
        if m is None:
            # default value
            return True
        return Handy.yn2bool(m.group(1), "PrintMotd")

    def set_printing_motd(self, value):
        assert not self._readOnly

        dst = "PrintMotd %s" % (Handy.bool2("yn", value)) if not value else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^PrintMotd\s+", dst):
            self._writeCfgFile()

    def is_printing_lastlog(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^PrintLastLog\s+(\S+)$")
        if m is None:
            # default value
            return True
        return Handy.yn2bool(m.group(1), "PrintLastLog")

    def set_printing_lastlog(self, value):
        assert not self._readOnly

        dst = "PrintLastLog %s" % (Handy.bool2("yn", value)) if not value else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^PrintLastLog\s+", dst):
            self._writeCfgFile()

    def get_permit_root_login(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^PermitRootLogin\s+(\S+)$")
        if m is None:
            # default value
            return "prohibit-password"
        if m.group(1) not in _permitRootLoginValidValues:
            raise FileFormatError("invalid value %s for PermitRootLogin directive" % (m.group(1)))
        return m.group(1)

    def set_permit_root_login(self, value):
        assert not self._readOnly
        assert value in _permitRootLoginValidValues

        dst = f"PermitRootLogin {value}" if value != "prohibit-password" else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^PermitRootLogin\s+", dst):
            self._writeCfgFile()

    def is_exposing_auth_info(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^ExposeAuthInfo\s+(\S+)$")
        if m is None:
            # default value
            return False
        return Handy.yn2bool(m.group(1), "ExposeAuthInfo")

    def set_exposing_auth_info(self, value):
        assert not self._readOnly

        dst = "ExposeAuthInfo %s" % (Handy.bool2("yn", value)) if not value else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^ExposeAuthInfo\s+", dst):
            self._writeCfgFile()

    def get_auth_sequences(self):
        validAuth = []

        # auth method
        m = Handy.lineListReSearch(self._cfgLines, r"^GSSAPIAuthentication\s+(\S+)$")
        if m is not None and Handy.yn2bool(m.group(1), "GSSAPIAuthentication"):                         # default is "no"
            validAuth.append("gssapi-with-mic")

        # auth method
        m = Handy.lineListReSearch(self._cfgLines, r"^HostbasedAuthentication\s+(\S+)$")
        if m is not None and Handy.yn2bool(m.group(1), "HostbasedAuthentication"):                      # default is "no"
            validAuth.append("host-based")

        # auth method
        m = Handy.lineListReSearch(self._cfgLines, r"^KbdInteractiveAuthentication\s+(\S+)$")
        if m is not None and Handy.yn2bool(m.group(1), "KbdInteractiveAuthentication"):                 # FIXME: default is ?
            validAuth.append("keyboard-interactive")

        # auth method
        m = Handy.lineListReSearch(self._cfgLines, r"^PasswordAuthentication\s+(\S+)$")
        if m is None or Handy.yn2bool(m.group(1), "PasswordAuthentication"):                            # default is "yes"
            validAuth.append("password")

        # auth method
        m = Handy.lineListReSearch(self._cfgLines, r"^PubkeyAuthentication\s+(\S+)$")
        if m is None or Handy.yn2bool(m.group(1), "PubkeyAuthentication"):                              # default is "yes"
            validAuth.append("publickey")
        else:
            if "AuthorizedKeysFile" in self._cfgLines:
                raise FileFormatError("AuthorizedKeysFile directive should not exist")
            if "AuthorizedKeysCommand" in self._cfgLines:
                raise FileFormatError("AuthorizedKeysCommand directive should not exist")
            if "AuthorizedKeysCommandUser" in self._cfgLines:
                raise FileFormatError("AuthorizedKeysCommandUser directive should not exist")

        # auth sequence
        m = Handy.lineListReSearch(self._cfgLines, r"^AuthenticationMethods\s+(\S+)$")
        if m is None:
            return validAuth
        else:
            ret = []
            for seq in m.group(1).split():
                seq = seq.split(",")
                for s in seq:
                    if s not in validAuth:
                        raise FileFormatError("invalid authentication \"%s\" in AuthenticationMethods directive" % (s))
                ret.append(seq)
            return ret

    def get_auth_param(self, auth_type):
        if auth_type == "gssapi-with-mic":
            return AuthParamGssapiWithMic()

        if auth_type == "host-based":
            return AuthParamHostBased()

        if auth_type == "keyboard-interactive":
            return AuthParamKeyboardInteractive()

        if auth_type == "password":
            return AuthParamPassword()

        if auth_type == "publickey":
            m = Handy.lineListReSearch(self._cfgLines, r"^PubkeyAuthentication\s+(\S+)$")
            assert m is None or Handy.yn2bool(m.group(1), "PubkeyAuthentication")

            keyFile = None
            bKeyFileIsDefault = False
            if True:
                m = Handy.lineListReSearch(self._cfgLines, r"^AuthorizedKeysFile\s+(\S+)$")
                if m is not None:
                    keyFile = m.group(1)
                    if keyFile == "/dev/null":
                        keyFile = None
                    elif os.path.isabs(keyFile):
                        if not os.path.isfile(keyFile):
                            raise FileFormatError("file specified in AuthorizedKeysFile directive does not exist")
                    else:
                        # user key file may not exist
                        pass
                else:
                    # default value
                    keyFile = ".ssh/authorized_keys"
                    bKeyFileIsDefault = True

            keyCmdFile = None
            keyCmdContentType = None
            if True:
                m = Handy.lineListReSearch(self._cfgLines, r"^AuthorizedKeysCommand\s+(\S+)$")
                if m is not None:
                    keyCmdFile = m.group(1)
                    if not os.path.isabs(keyCmdFile):
                        raise FileFormatError("absolute path must be used for AuthorizedKeysCommand directive")
                    try:
                        buf = pathlib.Path(keyCmdFile).read_text()
                        if buf == _authPermitAllScript.lstrip("\n"):
                            keyCmdContentType = AuthParamPublickey.KeyCommandScriptType.PERMIT_ALL
                        if buf == _authDenyAllScript.lstrip("\n"):
                            keyCmdContentType = AuthParamPublickey.KeyCommandScriptType.DENY_ALL
                        else:
                            keyCmdContentType = AuthParamPublickey.KeyCommandScriptType.CUSTOM
                    except FileNotFoundError:
                        raise FileFormatError("file specified in AuthorizedKeysCommand directive does not exist")

            m = Handy.lineListReSearch(self._cfgLines, r"^AuthorizedKeysCommandUser\s+(\S+)$")
            if m is not None:
                if m.group(1) != "nobody":
                    raise FileFormatError("only \"nobody\" can be used in AuthorizedKeysCommandUser directive")

            return AuthParamPublickey(keyFile, bKeyFileIsDefault, keyCmdFile, keyCmdContentType)

        assert False

    def set_auth_param(self, auth_type, **kwargs):
        assert not self._readOnly

        if auth_type == "gssapi-with-mic":
            assert False

        if auth_type == "host-based":
            assert False

        if auth_type == "keyboard-interactive":
            assert False

        if auth_type == "password":
            assert False

        if auth_type == "publickey":
            bModified = False
            if True:
                if "key_file" in kwargs:
                    if kwargs["key_file"] is None:
                        dst = "AuthorizedKeysFile /dev/null"
                    elif kwargs["key_file"] == ".ssh/authorized_keys":
                        dst = None
                    else:
                        dst = "AuthorizedKeysFile %s" % (kwargs["key_file"])
                else:
                    dst = None
                bModified |= Handy.lineListReplaceOrAddLine(self._cfgLines, r"^AuthorizedKeysFile\s+", dst)
            if True:
                if "keys_command_exec_line" in kwargs:
                    assert "keys_command_type" not in kwargs
                    assert "keys_command_script_content" not in kwargs
                    assert os.path.isabs(kwargs["keys_command_exec_line"])                 # we extraly enforces that non-default key command script file must have absolute path
                    dst = "AuthorizedKeysCommand %s" % (kwargs["keys_command_exec_line"])  # note: "keys_command_exec_line" can contain arguments such as %u, %k
                elif "keys_command_type" in kwargs:
                    assert "keys_command_script_content" not in kwargs
                    dst = "AuthorizedKeysCommand %s %%u %%k" % (os.path.join(self._cfgDir, "authorized_key_commmand"))
                elif "keys_command_script_content" in kwargs:
                    dst = "AuthorizedKeysCommand %s %%u %%k" % (os.path.join(self._cfgDir, "authorized_key_commmand"))
                else:
                    dst = None
                bModified |= Handy.lineListReplaceOrAddLine(self._cfgLines, r"^AuthorizedKeysCommand\s+", dst)
            if "keys_command_type" in kwargs:
                fullfn = os.path.join(self._cfgDir, "authorized_key_commmand")
                if kwargs["keys_command_type"] == AuthParamPublickey.KeyCommandScriptType.PERMIT_ALL.value:
                    buf = _authPermitAllScript.lstrip("\n")
                if kwargs["keys_command_type"] == AuthParamPublickey.KeyCommandScriptType.DENY_ALL.value:
                    buf = _authDenyAllScript.lstrip("\n")
                else:
                    assert False
                pathlib.Path(fullfn).write_text(buf)
                os.chmod(fullfn, 0o755)
            if "keys_command_script_content" in kwargs:
                fullfn = os.path.join(self._cfgDir, "authorized_key_commmand")
                pathlib.Path(fullfn).write_text(kwargs["keys_command_script_content"])
                os.chmod(fullfn, 0o755)
            if bModified:
                self._writeCfgFile()
            return

        assert False

    def has_force_command(self):
        return Handy.lineListReSearch(self._cfgLines, r"^ForceCommand\s+") is not None

    def get_force_command(self):
        return Handy.lineListReSearch(self._cfgLines, r"^ForceCommand\s+(\S+)$").group(1)

    def set_force_command_exec_line(self, exec_line):
        assert not self._readOnly
        assert not len(self.list_subsystems()) == 0       # with ForceCommand, Subsystem has no use

        dst = "ForceCommand %s" % (exec_line)
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^ForceCommand\s+", dst):
            self._writeCfgFile()

    def set_force_command_script_content(self, script_content):
        assert not self._readOnly
        assert not len(self.list_subsystems()) == 0       # with ForceCommand, Subsystem has no use

        fullfn = os.path.join(self._cfgDir, "force_command")
        pathlib.Path(fullfn).write_text(script_content)
        os.chmod(fullfn, 0o755)

        dst = "ForceCommand %s" % (fullfn)
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^ForceCommand\s+", dst):
            self._writeCfgFile()

    def get_log_level(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^LogLevel\s+(\S+)$")
        if m is None:
            # default value
            return "INFO"
        if m.group(1) not in _logLevelValidValues:
            raise FileFormatError("invalid log level %s" % (m.group(1)))
        return m.group(1)

    def set_log_level(self, value):
        assert not self._readOnly
        assert value in _logLevelValidValues

        dst = f"LogLevel {value}" if value != "INFO" else None
        if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^LogLevel\s+", dst):
            self._writeCfgFile()

    def list_subsystems(self):
        return [m.group(1) for m in Handy.lineListReSearchMulti(self._cfgLines, r"^Subsystem\s+(\S+)\s+")]

    def has_subsystem(self, name):
        return Handy.lineListReSearch(self._cfgLines, rf"^Subsystem\s+{re.escape(name)}\s+") is not None

    def get_subsystem_program_path(self, name):
        return Handy.lineListReSearch(self._cfgLines, rf"^Subsystem\s+{re.escape(name)}\s+(\S+)$").group(1)

    def add_or_update_builtin_subsystem(self, subsystem_type):
        assert not self._readOnly
        assert not self.has_force_command()       # with ForceCommand, Subsystem has no use

        if subsystem_type == "sftp":
            # sftp-server binary is deprecated by internal-sftp
            if Handy.lineListReplaceOrAddLine(self._cfgLines, r"^Subsystem\s+sftp\s+", "Subsystem sftp internal-sftp"):
                self._writeCfgFile()
        else:
            assert False

    def add_or_update_custom_subsystem(self, name, program_path):
        assert not self._readOnly
        assert not self.has_force_command()       # with ForceCommand, Subsystem has no use
        assert os.path.isabs(program_path)

        if Handy.lineListReplaceOrAddLine(self._cfgLines, rf"^Subsystem\s+{re.escape(name)}\s+", f"Subsystem {name} {program_path}"):
            self._writeCfgFile()

    def remove_subsystem(self, name):
        assert not self._readOnly

        if Handy.lineListReplaceOrAddLine(self._cfgLines, rf"^Subsystem\s+{re.escape(name)}\s+", None):
            self._writeCfgFile()

    def is_tcp_forwarding_enabled(self):
        m = Handy.lineListReSearch(self._cfgLines, r"^AllowTcpForwarding\s+(\S+)$")
        if m is None:
            # default value
            return True
        return Handy.yn2bool(m.group(1), "AllowTcpForwarding")

    def get_tcp_forwarding_specifications(self):
        assert self.is_tcp_forwarding_enabled()

        ret = [m.group(1) for m in Handy.lineListReSearchMulti(self._cfgLines, r"^PermitOpen\s+(\S+)$")]
        return ret if len(ret) > 0 else "any"

    def enable_tcp_forwarding_and_set_specifications(self, forwarding_specifications):
        assert not self._readOnly

        bModified = Handy.lineListRemoveLines(self._cfgLines, r"^AllowTcpForwarding\s+")

        bModified |= Handy.lineListRemoveLines(self._cfgLines, r"^PermitOpen\s+")
        if forwarding_specifications != "any":
            assert len(forwarding_specifications) > 0
            for spec in forwarding_specifications:
                assert re.fullmatch(r"(\S+)?:[0-9]+", spec)
                self._cfgLines.append(f"PermitOpen {spec}")
            bModified = True

        if bModified:
            self._writeCfgFile()

    def disable_tcp_forwarding(self):
        assert not self._readOnly

        bModified = False
        bModified |= Handy.lineListRemoveLines(self._cfgLines, r"^PermitOpen\s+")
        bModified |= Handy.lineListReplaceOrAddLine(self._cfgLines, r"^AllowTcpForwarding\s+", "AllowTcpForwarding no")
        if bModified:
            self._writeCfgFile()

    def get_accepted_environment_variables(self):
        return [m.group(1) for m in Handy.lineListReSearchMulti(self._cfgLines, r"^AcceptEnv\s+(\S+)$")]

    def accept_common_environment_variables(self):
        assert not self._readOnly

        bModified = False
        for env in Handy.COMMON_ENV_LIST:
            if not Handy.lineListReSearch(self._cfgLines, f"^AcceptEnv\\s+{re.escape(env)}$") is not None:
                self._cfgLines.append(f"AcceptEnv {env}")
                bModified = True
        if bModified:
            self._writeCfgFile()

    def accept_environment_variable(self, env):
        assert not self._readOnly

        if not Handy.lineListReSearch(self._cfgLines, f"^AcceptEnv\\s+{re.escape(env)}$") is not None:
            self._cfgLines.append(f"AcceptEnv {env}")
            self._writeCfgFile()

    def check(self, auto_fix=False, error_callback=None):
        if auto_fix:
            assert not self._readOnly

        if error_callback is None:
            error_callback = Util.doNothing

        # check mode of /etc/ssh/sshd_config
        if os.stat(self._getCfgFilePath()).st_mode != 0o600:
            error_callback('invalid mode for "%s"' % (self._getCfgFilePath()))

        # check keys
        keyFileList = []
        if self.has_host_key("rsa"):
            if os.stat(self._getHostKeyRsaFilePath(False)).st_mode != 0o600:
                error_callback('invalid mode for "%s"' % (self._getHostKeyRsaFilePath(False)))
            kbuf = paramiko.rsakey.RSAKey(filename=self._getHostKeyRsaFilePath(False))
            try:
                if pathlib.Path(self._getHostKeyRsaFilePath(True)).read_text() != f"{kbuf.get_name()} {kbuf.get_base64()}":
                    error_callback('invalid content in "%s"' % (self._getHostKeyRsaFilePath(True)))
            except FileNotFoundError:
                pass
            keyFileList.append(self._getHostKeyRsaFilePath(False))
        if self.has_host_key("ecdsa"):
            if os.stat(self._getHostKeyEcdsaFilePath(False)).st_mode != 0o600:
                error_callback('invalid mode for "%s"' % (self._getHostKeyEcdsaFilePath(False)))
            paramiko.ecdsakey.ECDSAKey(filename=self._getHostKeyEcdsaFilePath(False))
            try:
                if pathlib.Path(self._getHostKeyEcdsaFilePath(True)).read_text() != f"{kbuf.get_name()} {kbuf.get_base64()}":
                    error_callback('invalid content in "%s"' % (self._getHostKeyEcdsaFilePath(True)))
            except FileNotFoundError:
                pass
            keyFileList.append(self._getHostKeyEcdsaFilePath(False))
        if self.has_host_key("ed25519"):
            if os.stat(self._getHostKeyEd25519FilePath(False)).st_mode != 0o600:
                error_callback('invalid mode for "%s"' % (self._getHostKeyEd25519FilePath(False)))
            paramiko.ed25519key.Ed25519Key(filename=self._getHostKeyEd25519FilePath(False))
            try:
                if pathlib.Path(self._getHostKeyEd25519FilePath(True)).read_text() != f"{kbuf.get_name()} {kbuf.get_base64()}":
                    error_callback('invalid content in "%s"' % (self._getHostKeyEd25519FilePath(True)))
            except FileNotFoundError:
                pass
            keyFileList.append(self._getHostKeyEd25519FilePath(False))
        if len(keyFileList) == 0:
            error_callback("no host key found")

        # check redundant keys
        for m in Handy.lineListReSearchMulti(self._cfgLines, r"^HostKey\s+(\S+)$"):
            if m.group(1) not in keyFileList:
                error_callback("redundant HostKey directive found")

        # check by getting
        self.get_listen_addresses()
        self.get_port()
        self.is_using_pam()
        self.is_printing_motd()
        self.is_printing_lastlog()
        self.is_exposing_auth_info()
        self.get_auth_sequences()
        self.get_auth_param("gssapi-with-mic")
        self.get_auth_param("host-based")
        self.get_auth_param("keyboard-interactive")
        self.get_auth_param("password")
        self.get_auth_param("publickey")
        self.get_permit_root_login()
        if self.is_tcp_forwarding_enabled():
            self.get_tcp_forwarding_specifications()
        self.get_log_level()

        # allowDirectives
        allowDirectives = [
            "ListenAddress",
            "Port",
            "UsePAM",
            "PrintMotd",
            "PrintLastLog",
            "GSSAPIAuthentication",
            "HostbasedAuthentication",
            "KbdInteractiveAuthentication",
            "PasswordAuthentication",
            "PubkeyAuthentication",
            "LogLevel",
            "PermitRootLogin",
            "Subsystem",
            "AllowTcpForwarding",
            "PermitOpen",
            "AcceptEnv",
        ]
        for line in self._cfgLines:
            if all(re.search(rf"^{d}\s", line) is None for d in allowDirectives):
                error_callback('invalid line "%s"' % (line))

    def _getCfgFilePath(self):
        return os.path.join(self._cfgDir, "sshd_config")

    def _getHostKeyRsaFilePath(self, bPublicOrPrivate):
        return os.path.join(self._cfgDir, "ssh_host_rsa_key") + (".pub" if bPublicOrPrivate else "")

    def _getHostKeyEcdsaFilePath(self, bPublicOrPrivate):
        return os.path.join(self._cfgDir, "ssh_host_ecdsa_key") + (".pub" if bPublicOrPrivate else "")

    def _getHostKeyEd25519FilePath(self, bPublicOrPrivate):
        return os.path.join(self._cfgDir, "ssh_host_ed25519_key") + (".pub" if bPublicOrPrivate else "")

    def _writeCfgFile(self):
        os.makedirs(self._cfgDir, exist_ok=True)
        pathlib.Path(self._getCfgFilePath()).write_text("\n".join(self._cfgLines) + "\n")


class AuthParamGssapiWithMic:
    pass


class AuthParamHostBased:
    pass


class AuthParamKeyboardInteractive:
    pass


class AuthParamPassword:
    pass


class AuthParamPublickey:

    class KeyCommandScriptType(enum.Enum):
        PERMIT_ALL = "permit-all"
        DENY_ALL = "deny-all"
        CUSTOM = "custom"

    def __init__(self, keyFile, bKeyFileDefault, keyCmdFile, keyCmdType):
        if keyFile is None:
            assert not bKeyFileDefault

        if keyCmdFile is not None:
            assert isinstance(keyCmdType, self.KeyCommandScriptType)
        else:
            assert keyCmdType is None

        self._keyFile = keyFile
        self._bDefaultKeyFile = bKeyFileDefault
        self._keyCmd = keyCmdFile
        self._keyCmdType = keyCmdType

    def is_using_key_file(self):
        return self._keyFile is not None

    def is_using_default_key_file(self):
        assert self.is_using_key_file()
        return self._bDefaultKeyFile

    def is_using_global_key_file(self):
        assert self.is_using_key_file()
        return os.path.isabs(self._keyFile)

    def get_key_file(self):
        assert self.is_using_key_file()
        return self._keyFile

    def has_keys_command_script(self):
        return self._keyCmd is not None

    def get_keys_command_script_file(self):
        assert self.has_keys_command_script()
        return self._keyCmd

    def get_keys_command_script_type(self):
        assert self.has_keys_command_script()
        return self._keyCmdType


_permitRootLoginValidValues = [
    "yes",
    "prohibit-password",
    "forced-commands-only",
    "no",
]


_logLevelValidValues = [
    "QUIET",
    "FATAL",
    "ERROR",
    "INFO",
    "VERBOSE",
    "DEBUG",
    "DEBUG1",
    "DEBUG2",
    "DEBUG3",
]


_authPermitAllScript = """
#!/bin/sh
# directly echo client public key (%k argument)
echo "$2"
"""


_authDenyAllScript = """
#!/bin/sh
# don't echo any key
"""
